from __future__ import annotations

import filecmp
import os
import platform
import re
import stat
import sys
from contextlib import AbstractContextManager, nullcontext as does_not_raise
from decimal import Decimal
from enum import Enum
from pathlib import Path
from textwrap import dedent, indent
from time import sleep
from typing import Any
from unittest import mock

import pytest
import yaml
from plumbum import local

import copier
from copier import run_copy
from copier._types import AnyByStrDict
from copier.errors import ForbiddenPathError

from .helpers import (
    BRACKET_ENVOPS,
    BRACKET_ENVOPS_JSON,
    PROJECT_TEMPLATE,
    assert_file,
    build_file_tree,
    git,
    git_save,
    render,
)


def test_project_not_found(tmp_path: Path) -> None:
    with pytest.raises(ValueError):
        copier.run_copy("foobar", tmp_path)

    with pytest.raises(ValueError):
        copier.run_copy(__file__, tmp_path)


def test_copy_with_non_version_tags(tmp_path_factory: pytest.TempPathFactory) -> None:
    src, dst = map(tmp_path_factory.mktemp, ("src", "dst"))
    build_file_tree(
        {
            (src / ".copier-answers.yml.jinja"): (
                """\
                # Changes here will be overwritten by Copier
                {{ _copier_answers|to_nice_yaml }}
                """
            ),
            (src / "copier.yaml"): (
                """\
                _tasks:
                    - cat v1.txt
                """
            ),
            (src / "v1.txt"): "file only in v1",
        }
    )
    with local.cwd(src):
        git("init")
        git("add", ".")
        git("commit", "-m1")
        git("tag", "test_tag.post23+deadbeef")

    run_copy(
        str(src),
        dst,
        defaults=True,
        overwrite=True,
        unsafe=True,
        vcs_ref="HEAD",
    )


@pytest.mark.parametrize(
    "tag2, use_prereleases, expected_version",
    [
        ("1.2.3.post1", True, "2"),
        ("1.2.4.dev1", True, "2"),
        ("1.2.3.post1", False, "2"),
        ("1.2.4.dev1", False, "1"),
    ],
)
@pytest.mark.parametrize("prefix", ["", "v"])
def test_tag_autodetect_v_prefix_optional(
    tmp_path_factory: pytest.TempPathFactory,
    tag2: str,
    use_prereleases: bool,
    expected_version: str,
    prefix: str,
) -> None:
    """Tags with and without `v` prefix should work the same."""
    src, dst = map(tmp_path_factory.mktemp, ("src", "dst"))
    build_file_tree(
        {
            (src / "version.txt"): "1",
            (
                src / "{{_copier_conf.answers_file}}.jinja"
            ): "{{ _copier_answers|to_nice_yaml -}}",
        }
    )
    # One stable tag
    git_save(src, tag=f"{prefix}1.2.3")
    # One pre or post release tag
    (src / "version.txt").write_text("2")
    git_save(src, tag=f"{prefix}{tag2}")
    # One untagged change
    (src / "version.txt").write_text("3")
    git_save(src)
    # Copy, leaving latest tag auto-detection
    copier.run_copy(
        str(src),
        dst,
        use_prereleases=use_prereleases,
        defaults=True,
        unsafe=True,
    )
    # Check it works as expected
    assert (dst / "version.txt").read_text() == expected_version


def test_copy_with_non_version_tags_and_vcs_ref(
    tmp_path_factory: pytest.TempPathFactory,
) -> None:
    src, dst = map(tmp_path_factory.mktemp, ("src", "dst"))
    build_file_tree(
        {
            (src / ".copier-answers.yml.jinja"): (
                """\
                # Changes here will be overwritten by Copier
                {{ _copier_answers|to_nice_yaml }}
                """
            ),
            (src / "copier.yaml"): (
                """\
                _tasks:
                    - cat v1.txt
                """
            ),
            (src / "v1.txt"): "file only in v1",
        }
    )
    with local.cwd(src):
        git("init")
        git("add", ".")
        git("commit", "-m1")
        git("tag", "test_tag.post23+deadbeef")

    run_copy(
        str(src),
        dst,
        defaults=True,
        overwrite=True,
        unsafe=True,
        vcs_ref="test_tag.post23+deadbeef",
    )


def test_copy_with_vcs_ref_branch(tmp_path_factory: pytest.TempPathFactory) -> None:
    src, dst = map(tmp_path_factory.mktemp, ("src", "dst"))
    with local.cwd(src):
        git("init")
        main_branch = git("branch", "--show-current").strip()
        git("add", ".")
        git("commit", "-m1", "--allow-empty")
        git("checkout", "-b", "branch")
        git("commit", "-m2", "--allow-empty")
        git("checkout", main_branch)
    run_copy(str(src), dst, vcs_ref="branch")


def test_copy(tmp_path: Path) -> None:
    render(tmp_path)

    generated = (tmp_path / "pyproject.toml").read_text()
    control = (Path(__file__).parent / "reference_files" / "pyproject.toml").read_text()
    assert generated == control

    assert_file(tmp_path, "doc", "mañana.txt")
    assert_file(tmp_path, "doc", "images", "nslogo.gif")

    p1 = tmp_path / "awesome" / "hello.txt"
    p2 = PROJECT_TEMPLATE / "[[ myvar ]]" / "hello.txt"
    assert filecmp.cmp(p1, p2)

    assert (tmp_path / "README.txt").read_text() == "This is the README for Copier.\n"

    p1 = tmp_path / "awesome.txt"
    p2 = PROJECT_TEMPLATE / "[[ myvar ]].txt"
    assert filecmp.cmp(p1, p2)

    assert not (tmp_path / "[% if not py3 %]py2_only.py[% endif %]").exists()
    assert not (tmp_path / "[% if py3 %]py3_only.py[% endif %]").exists()
    assert not (tmp_path / "py2_only.py").exists()
    assert (tmp_path / "py3_only.py").exists()
    assert not (
        tmp_path / "[% if not py3 %]py2_folder[% endif %]" / "thing.py"
    ).exists()
    assert not (tmp_path / "[% if py3 %]py3_folder[% endif %]" / "thing.py").exists()
    assert not (tmp_path / "py2_folder" / "thing.py").exists()
    assert (tmp_path / "py3_folder" / "thing.py").exists()

    assert (tmp_path / "aaaa.txt").exists()


@pytest.mark.impure
def test_copy_repo(tmp_path: Path) -> None:
    copier.run_copy(
        "gh:copier-org/copier.git",
        tmp_path,
        vcs_ref="HEAD",
        quiet=True,
        exclude=["*", "!README.*"],
    )
    assert (tmp_path / "README.md").exists()


def test_default_exclude(tmp_path: Path) -> None:
    render(tmp_path)
    assert not (tmp_path / ".svn").exists()


def test_include_file(tmp_path: Path) -> None:
    render(tmp_path, exclude=["!.svn"])
    assert_file(tmp_path, ".svn")


def test_include_pattern(tmp_path: Path) -> None:
    render(tmp_path, exclude=["!.*"])
    assert (tmp_path / ".svn").exists()


@pytest.mark.xfail(
    condition=platform.system() == "Darwin",
    reason="Mac claims to use UTF-8 filesystem, but behaves differently.",
    strict=True,
)
def test_exclude_file(tmp_path: Path) -> None:
    print(f"Filesystem encoding is {sys.getfilesystemencoding()}")
    # This file name is b"man\xcc\x83ana.txt".decode()
    render(tmp_path, exclude=["mañana.txt"])
    assert not (tmp_path / "doc" / "mañana.txt").exists()
    # This file name is b"ma\xc3\xb1ana.txt".decode()
    assert (tmp_path / "doc" / "mañana.txt").exists()
    assert (tmp_path / "doc" / "manana.txt").exists()


def test_exclude_extends(tmp_path_factory: pytest.TempPathFactory) -> None:
    """Exclude argument extends the original exclusions instead of replacing them."""
    src, dst = map(tmp_path_factory.mktemp, ("src", "dst"))
    build_file_tree({src / "test.txt": "Test text", src / "test.json": '"test json"'})
    # Convert to git repo
    with local.cwd(src):
        git("init")
        git("add", ".")
        git("commit", "-m", "hello world")
    copier.run_copy(str(src), dst, exclude=["*.txt"])
    assert (dst / "test.json").is_file()
    assert not (dst / "test.txt").exists()
    # .git exists in src, but not in dst because it is excluded by default
    assert not (dst / ".git").exists()


def test_exclude_replaces(tmp_path_factory: pytest.TempPathFactory) -> None:
    """Exclude in copier.yml replaces default values."""
    src, dst = map(tmp_path_factory.mktemp, ("src", "dst"))
    build_file_tree(
        {
            src / "test.txt": "Test text",
            src / "test.json": '"test json"',
            src / "test.yaml": '"test yaml"',
            src / "copier.yaml.jinja": "purpose: template inception",
            src / "copier.yml": "_exclude: ['*.json']",
        }
    )
    copier.run_copy(str(src), dst, exclude=["*.txt"])
    assert (dst / "test.yaml").is_file()
    assert not (dst / "test.txt").exists()
    assert not (dst / "test.json").exists()
    assert (dst / "copier.yml").exists()
    assert (dst / "copier.yaml").is_file()


def test_skip_if_exists(tmp_path: Path) -> None:
    copier.run_copy(str(Path("tests", "demo_skip_dst")), tmp_path)
    copier.run_copy(
        "tests/demo_skip_src",
        tmp_path,
        skip_if_exists=["b.noeof.txt", "meh/c.noeof.txt"],
        defaults=True,
        overwrite=True,
    )

    assert (tmp_path / "a.noeof.txt").read_text() == "SKIPPED"
    assert (tmp_path / "b.noeof.txt").read_text() == "SKIPPED"
    assert (tmp_path / "meh" / "c.noeof.txt").read_text() == "SKIPPED"


def test_timestamp_identical(tmp_path: Path) -> None:
    copier.run_copy(str(Path("tests", "demo_skip_dst")), tmp_path)
    modification_time_before = (tmp_path / "a.noeof.txt").stat().st_mtime
    sleep(2)
    copier.run_copy(str(Path("tests", "demo_skip_dst")), tmp_path)
    modification_time_after = (tmp_path / "a.noeof.txt").stat().st_mtime

    assert modification_time_before == modification_time_after


def test_skip_if_exists_rendered_patterns(tmp_path: Path) -> None:
    copier.run_copy("tests/demo_skip_dst", tmp_path)
    copier.run_copy(
        "tests/demo_skip_src",
        tmp_path,
        data={"name": "meh"},
        skip_if_exists=["{{ name }}/c.noeof.txt"],
        defaults=True,
        overwrite=True,
    )
    assert (tmp_path / "a.noeof.txt").read_text() == "SKIPPED"
    assert (tmp_path / "b.noeof.txt").read_text() == "OVERWRITTEN"
    assert (tmp_path / "meh" / "c.noeof.txt").read_text() == "SKIPPED"


def test_skip_option(tmp_path: Path) -> None:
    render(tmp_path)
    path = tmp_path / "pyproject.toml"
    content = "lorem ipsum"
    path.write_text(content)
    render(tmp_path, skip_if_exists=["**"])
    assert path.read_text() == content


def test_force_option(tmp_path: Path) -> None:
    render(tmp_path)
    path = tmp_path / "pyproject.toml"
    content = "lorem ipsum"
    path.write_text(content)
    render(tmp_path, defaults=True, overwrite=True)
    assert path.read_text() != content


def test_pretend_option(tmp_path: Path) -> None:
    render(tmp_path, pretend=True)
    assert not (tmp_path / "doc").exists()
    assert not (tmp_path / "config.py").exists()
    assert not (tmp_path / "pyproject.toml").exists()


@pytest.mark.parametrize("generate", [True, False])
def test_empty_dir(tmp_path_factory: pytest.TempPathFactory, generate: bool) -> None:
    src, dst = map(tmp_path_factory.mktemp, ("src", "dst"))
    build_file_tree(
        {
            (src / "copier.yaml"): (
                f"""\
                _subdirectory: tpl
                _templates_suffix: .jinja
                _envops: {BRACKET_ENVOPS_JSON}
                do_it:
                    type: bool
                """
            ),
            (src / "tpl" / "[% if do_it %]one_dir[% endif %]" / "one.txt.jinja"): (
                "[[ do_it ]]"
            ),
            (src / "tpl" / "two.txt"): "[[ do_it ]]",
            (src / "tpl" / "[% if do_it %]three.txt[% endif %].jinja"): "[[ do_it ]]",
            (src / "tpl" / "[% if do_it %]four.txt[% endif %].jinja"): Path(
                "[% if do_it %]three.txt[% endif %].jinja"
            ),
            (src / "tpl" / "five" / "[% if do_it %]six.txt[% endif %].jinja"): (
                "[[ do_it ]]"
            ),
        },
    )
    copier.run_copy(str(src), dst, {"do_it": generate}, defaults=True, overwrite=True)
    assert (dst / "five").is_dir()
    assert (dst / "two.txt").read_text() == "[[ do_it ]]"
    assert (dst / "one_dir").exists() == generate
    assert (dst / "three.txt").exists() == generate
    assert (dst / "four.txt").exists() == generate
    assert (dst / "one_dir").is_dir() == generate
    assert (dst / "one_dir" / "one.txt").is_file() == generate
    if generate:
        assert (dst / "one_dir" / "one.txt").read_text() == repr(generate)
        assert (dst / "three.txt").read_text() == repr(generate)
        assert not (dst / "four.txt").is_symlink()
        assert (dst / "four.txt").read_text() == repr(generate)
        assert (dst / "five" / "six.txt").read_text() == repr(generate)


@pytest.mark.skipif(
    condition=platform.system() == "Windows",
    reason="Windows does not have UNIX-like permissions",
)
@pytest.mark.parametrize(
    "permissions",
    (
        stat.S_IRUSR,
        stat.S_IRWXU,
        stat.S_IRWXU | stat.S_IRWXG,
        stat.S_IRWXU | stat.S_IRWXO,
        stat.S_IRWXU | stat.S_IRWXG | stat.S_IRWXO,
    ),
)
def test_preserved_permissions(
    tmp_path_factory: pytest.TempPathFactory, permissions: int
) -> None:
    src, dst = map(tmp_path_factory.mktemp, ("src", "dst"))
    src_file = src / "the_file"
    src_file.write_text("content")
    src_file.chmod(permissions)
    copier.run_copy(str(src), dst, defaults=True, overwrite=True)
    assert ((dst / "the_file").stat().st_mode & permissions) == permissions


def test_commit_hash(tmp_path_factory: pytest.TempPathFactory) -> None:
    src, dst = map(tmp_path_factory.mktemp, ("src", "dst"))
    build_file_tree(
        {
            src / "commit.jinja": "{{_copier_conf.vcs_ref_hash}}",
            src / "tag.jinja": "{{_copier_answers._commit}}",
        }
    )
    with local.cwd(src):
        git("init")
        git("add", "-A")
        git("commit", "-m1")
        git("tag", "1.0")
        commit = git("rev-parse", "HEAD").strip()
    copier.run_copy(str(src), dst)
    assert (dst / "tag").read_text() == "1.0"
    assert (dst / "commit").read_text() == commit


def test_value_with_forward_slash(tmp_path_factory: pytest.TempPathFactory) -> None:
    src, dst = map(tmp_path_factory.mktemp, ("src", "dst"))
    build_file_tree(
        {
            (src / "{{ filename.replace('.', _copier_conf.sep) }}.txt"): (
                "This is template."
            ),
        }
    )
    copier.run_copy(str(src), dst, data={"filename": "a.b.c"})
    assert (dst / "a" / "b" / "c.txt").read_text() == "This is template."


@pytest.mark.parametrize(
    "spec, value, expected",
    [
        ({"type": "int"}, "no-int", pytest.raises(ValueError)),
        ({"type": "int"}, "0", does_not_raise()),
        (
            {
                "type": "int",
                "validator": "[% if q <= 0 %]must be positive[% endif %]",
            },
            "0",
            pytest.raises(ValueError),
        ),
        (
            {
                "type": "int",
                "validator": "[% if q <= 0 %]must be positive[% endif %]",
            },
            "1",
            does_not_raise(),
        ),
        ({"type": "str"}, "string", does_not_raise()),
        ({"type": "str"}, b"bytes", does_not_raise()),
        ({"type": "str"}, bytearray("abc", "utf-8"), does_not_raise()),
        ({"type": "str"}, 1, does_not_raise()),
        ({"type": "str"}, 1.1, does_not_raise()),
        ({"type": "str"}, True, does_not_raise()),
        ({"type": "str"}, False, does_not_raise()),
        ({"type": "str"}, Decimal(1.1), does_not_raise()),
        ({"type": "str"}, Enum("A", ["a", "b"], type=str).a, does_not_raise()),  # type: ignore[attr-defined]
        ({"type": "str"}, Enum("A", ["a", "b"]).a, pytest.raises(ValueError)),  # type: ignore[attr-defined]
        ({"type": "str"}, object(), pytest.raises(ValueError)),
        ({"type": "str"}, {}, pytest.raises(ValueError)),
        ({"type": "str"}, [], pytest.raises(ValueError)),
        (
            {
                "type": "str",
                "validator": "[% if q|length < 3 %]too short[% endif %]",
            },
            "ab",
            pytest.raises(ValueError),
        ),
        (
            {
                "type": "str",
                "validator": "[% if q|length < 3 %]too short[% endif %]",
            },
            "abc",
            does_not_raise(),
        ),
        (
            {
                "type": "str",
                "secret": True,
                "default": "",
                "validator": "[% if q|length < 3 %]too short[% endif %]",
            },
            "",
            pytest.raises(
                ValueError, match=r"^Validation error for question '\w+': too short$"
            ),
        ),
        ({"type": "bool"}, "true", does_not_raise()),
        ({"type": "bool"}, "false", does_not_raise()),
        (
            {"type": "int", "choices": [1, 2, 3]},
            "1",
            does_not_raise(),
        ),
        (
            {"type": "int", "choices": [1, 2, 3]},
            "4",
            pytest.raises(ValueError),
        ),
        (
            {
                "type": "int",
                "choices": [1, ["2", {"value": None}], 3],
            },
            "2",
            does_not_raise(),
        ),
        (
            {
                "type": "int",
                "choices": [1, ["2", {"value": None, "disabled": ""}], 3],
            },
            "2",
            does_not_raise(),
        ),
        (
            {
                "type": "int",
                "choices": [1, ["2", {"value": None, "validator": "disabled"}], 3],
            },
            "2",
            pytest.raises(ValueError, match="Invalid choice: disabled"),
        ),
        (
            {"type": "int", "choices": {"one": 1, "two": 2, "three": 3}},
            "1",
            does_not_raise(),
        ),
        (
            {"type": "int", "choices": {"one": 1, "two": 2, "three": 3}},
            "4",
            pytest.raises(ValueError),
        ),
        (
            {"type": "int", "choices": {"one": 1, "two": {"value": 2}, "three": 3}},
            "2",
            does_not_raise(),
        ),
        (
            {
                "type": "int",
                "choices": {
                    "one": 1,
                    "two": {"value": 2, "validator": ""},
                    "three": 3,
                },
            },
            "2",
            does_not_raise(),
        ),
        (
            {
                "type": "int",
                "choices": {
                    "one": 1,
                    "two": {"value": 2, "validator": "disabled"},
                    "three": 3,
                },
            },
            "2",
            pytest.raises(ValueError, match="Invalid choice: disabled"),
        ),
        (
            {"type": "str", "choices": {"one": None, "two": None, "three": None}},
            "one",
            does_not_raise(),
        ),
        (
            {"type": "str", "choices": {"one": None, "two": None, "three": None}},
            "four",
            pytest.raises(ValueError),
        ),
        (
            {
                "type": "str",
                "choices": {
                    "banana": {"value": "yellow", "validator": "invalid"},
                    "tshirt": {"value": "yellow", "validator": ""},
                },
            },
            "yellow",
            does_not_raise(),
        ),
        (
            {"type": "path"},
            "/etc/hostname",
            does_not_raise(),
        ),
        (
            {"type": "path"},
            "/this/does/not/exist",
            does_not_raise(),
        ),
        (
            {
                "type": "path",
                "choices": {
                    "one": None,
                    "two": "/etc/hostname",
                    "three": "/does/not/exist",
                },
            },
            "/etc/hostname",
            does_not_raise(),
        ),
        (
            {
                "type": "path",
                "choices": {
                    "one": None,
                    "two": "/etc/hostname",
                    "three": "/does/not/exist",
                },
            },
            "/does/not/exist",
            does_not_raise(),
        ),
        (
            {
                "type": "path",
                "choices": {
                    "one": None,
                    "two": "/etc/hostname",
                    "three": "/does/not/exist",
                },
            },
            "/another/path/that/does/not/exist",
            pytest.raises(ValueError),
        ),
        (
            {"type": "path", "validator": ""},
            "/this/does/not/exist",
            does_not_raise(),
        ),
        (
            {
                "type": "path",
                "validator": "[% if q | length < 1 or q[0] != '/' %]must be absolute path[% endif %]",
            },
            "/this/does/not/exist",
            does_not_raise(),
        ),
        (
            {
                "type": "path",
                "validator": "[% if q | length < 1 or q[0] != '/' %]must be absolute path[% endif %]",
            },
            "./local/path",
            pytest.raises(ValueError),
        ),
        (
            {"type": "yaml", "choices": {"one": None, "two": 2, "three": "null"}},
            "one",
            does_not_raise(),
        ),
        (
            {"type": "yaml", "choices": {"one": None, "two": 2, "three": "null"}},
            "2",
            does_not_raise(),
        ),
        (
            {"type": "yaml", "choices": {"one": None, "two": 2, "three": "null"}},
            "null",
            does_not_raise(),
        ),
        (
            {"type": "yaml", "choices": {"one": None, "two": 2, "three": "null"}},
            "two",
            pytest.raises(ValueError),
        ),
        (
            {"type": "yaml", "choices": {"one": None, "two": 2, "three": "null"}},
            "three",
            pytest.raises(ValueError),
        ),
        (
            {"type": "yaml", "choices": {"array": "[a, b]"}},
            "[a, b]",
            does_not_raise(),
        ),
        (
            {"type": "yaml", "choices": {"object": "k: v"}},
            "k: v",
            does_not_raise(),
        ),
        (
            {"type": "yaml", "choices": [{"one": 1, "two": 2}]},
            "one: 1\ntwo: 2",
            does_not_raise(),
        ),
        (
            {"type": "yaml", "choices": {"complex": {"value": {"key": "value"}}}},
            "key: value",
            does_not_raise(),
        ),
        (
            {
                "type": "yaml",
                "choices": {"complex": {"value": {"key": "value"}, "validator": ""}},
            },
            "key: value",
            does_not_raise(),
        ),
        (
            {
                "type": "yaml",
                "choices": {
                    "complex": {"value": {"key": "value"}, "validator": "disabled"}
                },
            },
            "key: value",
            pytest.raises(ValueError, match="Invalid choice: disabled"),
        ),
        (
            {"type": "yaml", "choices": {"complex": {"key": "value"}}},
            "key: value",
            pytest.raises(KeyError, match="Property 'value' is required"),
        ),
        (
            {"type": "yaml", "choices": {"complex": {"value": 1, "validator": {}}}},
            "1",
            pytest.raises(ValueError, match="Property 'validator' must be a string"),
        ),
        (
            {"type": "yaml", "choices": [{"key": "value"}]},
            "key: value",
            does_not_raise(),
        ),
        (
            {"type": "yaml", "choices": [{"value": 1, "validator": {}}]},
            "value: 1\nvalidator: {}",
            does_not_raise(),
        ),
        (
            {"type": "yaml", "choices": [["complex", {"value": {"key": "value"}}]]},
            "key: value",
            does_not_raise(),
        ),
        (
            {
                "type": "yaml",
                "choices": [["complex", {"value": {"key": "value"}, "validator": ""}]],
            },
            "key: value",
            does_not_raise(),
        ),
        (
            {
                "type": "yaml",
                "choices": [
                    ["complex", {"value": {"key": "value"}, "validator": "disabled"}]
                ],
            },
            "key: value",
            pytest.raises(ValueError, match="Invalid choice: disabled"),
        ),
        (
            {"type": "yaml", "choices": [["complex", {"key": "value"}]]},
            "key: value",
            pytest.raises(KeyError, match="Property 'value' is required"),
        ),
        (
            {"type": "yaml", "choices": [["complex", {"value": 1, "validator": {}}]]},
            "1",
            pytest.raises(ValueError, match="Property 'validator' must be a string"),
        ),
        (
            {
                "type": "str",
                "choices": ["one", "two", "three"],
                "multiselect": True,
                "validator": "[% if not q %]At least one choice required[% endif %]",
            },
            [],
            pytest.raises(ValueError, match="At least one choice required"),
        ),
    ],
)
def test_validate_init_data(
    tmp_path_factory: pytest.TempPathFactory,
    spec: AnyByStrDict,
    value: Any,
    expected: AbstractContextManager[None],
) -> None:
    src, dst = map(tmp_path_factory.mktemp, ("src", "dst"))
    build_file_tree(
        {
            (src / "copier.yml"): yaml.dump(
                {
                    "_envops": BRACKET_ENVOPS,
                    "_templates_suffix": ".jinja",
                    "q": spec,
                }
            ),
        }
    )
    with expected:
        copier.run_copy(str(src), dst, data={"q": value})


@pytest.mark.parametrize("defaults", [False, True])
def test_validate_init_data_with_skipped_question(
    tmp_path_factory: pytest.TempPathFactory, defaults: bool
) -> None:
    src, dst = map(tmp_path_factory.mktemp, ("src", "dst"))
    build_file_tree(
        {
            (src / "copier.yml"): (
                f"""\
                _envops: {BRACKET_ENVOPS_JSON}

                kind:
                    type: str
                    help: What kind do you need?
                    choices:
                        foo: foo
                        bar: bar

                testbar:
                    when: "[[ kind == 'bar' ]]"
                    type: str
                    help: any string bar?

                testfoo:
                    when: "[[ kind == 'foo' ]]"
                    type: str
                    help: any string foo?
                """
            ),
            (src / "result.jinja"): (
                """\
                [[ kind ]]
                [[ testbar ]]
                [[ testfoo ]]
                """
            ),
        }
    )
    copier.run_copy(
        str(src), dst, defaults=defaults, data={"kind": "foo", "testfoo": "helloworld"}
    )
    assert (dst / "result").read_text() == "foo\n\nhelloworld\n"


@pytest.mark.parametrize(
    "type_name",
    ["str", "int", "float", "bool", "json", "yaml"],
)
def test_required_question_without_data(
    tmp_path_factory: pytest.TempPathFactory, type_name: str
) -> None:
    src, dst = map(tmp_path_factory.mktemp, ("src", "dst"))
    build_file_tree(
        {
            (src / "copier.yml"): yaml.safe_dump(
                {
                    "question": {
                        "type": type_name,
                    }
                }
            )
        }
    )
    with pytest.raises(ValueError, match='Question "question" is required'):
        copier.run_copy(str(src), dst, defaults=True)


@pytest.mark.parametrize(
    "type_name, choices",
    [
        ("str", ["one", "two", "three"]),
        ("int", [1, 2, 3]),
        ("float", [1.0, 2.0, 3.0]),
        ("json", ["[1]", "[2]", "[3]"]),
        ("yaml", ["- 1", "- 2", "- 3"]),
    ],
)
def test_required_choice_question_without_data(
    tmp_path_factory: pytest.TempPathFactory, type_name: str, choices: list[Any]
) -> None:
    src, dst = map(tmp_path_factory.mktemp, ("src", "dst"))
    build_file_tree(
        {
            (src / "copier.yml"): yaml.safe_dump(
                {
                    "question": {
                        "type": type_name,
                        "choices": choices,
                    }
                }
            )
        }
    )
    with pytest.raises(ValueError, match='Question "question" is required'):
        copier.run_copy(str(src), dst, defaults=True)


@pytest.mark.parametrize(
    "spec, expected",
    [
        ({"type": "str", "default": "string"}, does_not_raise()),
        ({"type": "str", "default": "1.0"}, does_not_raise()),
        ({"type": "str", "default": 1.0}, does_not_raise()),
        ({"type": "str", "default": None}, pytest.raises(TypeError)),
        (
            {
                "type": "str",
                "default": "",
                "validator": "[% if q|length < 3 %]too short[% endif %]",
            },
            pytest.raises(ValueError),
        ),
        pytest.param(
            {
                "type": "str",
                "default": "",
                "secret": True,
                "validator": "[% if q|length < 3 %]too short[% endif %]",
            },
            pytest.raises(ValueError),
            marks=pytest.mark.xfail(
                reason="Default values of secrets are currently not validated"
            ),
        ),
        ({"type": "int", "default": 1}, does_not_raise()),
        ({"type": "int", "default": 1.0}, does_not_raise()),
        ({"type": "int", "default": "1"}, does_not_raise()),
        ({"type": "int", "default": "1.0"}, pytest.raises(ValueError)),
        ({"type": "int", "default": "no-int"}, pytest.raises(ValueError)),
        ({"type": "int", "default": None}, pytest.raises(TypeError)),
        ({"type": "int", "default": {}}, pytest.raises(TypeError)),
        ({"type": "int", "default": []}, pytest.raises(TypeError)),
        ({"type": "float", "default": 1.1}, does_not_raise()),
        ({"type": "float", "default": 1}, does_not_raise()),
        ({"type": "float", "default": "1.1"}, does_not_raise()),
        ({"type": "float", "default": "no-float"}, pytest.raises(ValueError)),
        ({"type": "float", "default": None}, pytest.raises(TypeError)),
        ({"type": "float", "default": {}}, pytest.raises(TypeError)),
        ({"type": "float", "default": []}, pytest.raises(TypeError)),
        ({"type": "bool", "default": True}, does_not_raise()),
        ({"type": "bool", "default": False}, does_not_raise()),
        ({"type": "bool", "default": "y"}, does_not_raise()),
        ({"type": "bool", "default": "n"}, does_not_raise()),
        ({"type": "bool", "default": None}, pytest.raises(TypeError)),
        ({"type": "json", "default": '"string"'}, does_not_raise()),
        ({"type": "json", "default": "1"}, does_not_raise()),
        ({"type": "json", "default": 1}, does_not_raise()),
        ({"type": "json", "default": "1.1"}, does_not_raise()),
        ({"type": "json", "default": 1.1}, does_not_raise()),
        ({"type": "json", "default": "true"}, does_not_raise()),
        ({"type": "json", "default": True}, does_not_raise()),
        ({"type": "json", "default": "false"}, does_not_raise()),
        ({"type": "json", "default": False}, does_not_raise()),
        ({"type": "json", "default": "{}"}, does_not_raise()),
        ({"type": "json", "default": {}}, does_not_raise()),
        ({"type": "json", "default": "[]"}, does_not_raise()),
        ({"type": "json", "default": []}, does_not_raise()),
        ({"type": "json", "default": "null"}, does_not_raise()),
        ({"type": "json", "default": None}, does_not_raise()),
        ({"type": "path", "default": None}, pytest.raises(TypeError)),
        ({"type": "path", "default": ""}, does_not_raise()),
        ({"type": "path", "default": "/etc/hostname"}, does_not_raise()),
        ({"type": "path", "default": "/does/not/exist"}, does_not_raise()),
        ({"type": "yaml", "default": '"string"'}, does_not_raise()),
        ({"type": "yaml", "default": "string"}, does_not_raise()),
        ({"type": "yaml", "default": "1"}, does_not_raise()),
        ({"type": "yaml", "default": 1}, does_not_raise()),
        ({"type": "yaml", "default": "1.1"}, does_not_raise()),
        ({"type": "yaml", "default": 1.1}, does_not_raise()),
        ({"type": "yaml", "default": "true"}, does_not_raise()),
        ({"type": "yaml", "default": True}, does_not_raise()),
        ({"type": "yaml", "default": "false"}, does_not_raise()),
        ({"type": "yaml", "default": False}, does_not_raise()),
        ({"type": "yaml", "default": "{}"}, does_not_raise()),
        ({"type": "yaml", "default": {}}, does_not_raise()),
        ({"type": "yaml", "default": "[]"}, does_not_raise()),
        ({"type": "yaml", "default": []}, does_not_raise()),
        ({"type": "yaml", "default": "null"}, does_not_raise()),
        ({"type": "yaml", "default": None}, does_not_raise()),
    ],
)
def test_validate_default_value(
    tmp_path_factory: pytest.TempPathFactory,
    spec: AnyByStrDict,
    expected: AbstractContextManager[None],
) -> None:
    src, dst = map(tmp_path_factory.mktemp, ("src", "dst"))
    build_file_tree({(src / "copier.yml"): yaml.dump({"q": spec})})
    with expected:
        copier.run_copy(str(src), dst, defaults=True)


def test_multiselect_choices_preserve_order(
    tmp_path_factory: pytest.TempPathFactory,
) -> None:
    src, dst = map(tmp_path_factory.mktemp, ("src", "dst"))
    build_file_tree(
        {
            (src / "copier.yml"): yaml.dump(
                {
                    "q": {
                        "type": "str",
                        "choices": ["one", "two", "three"],
                        "multiselect": True,
                    }
                }
            ),
            (src / "q.yml.jinja"): "{{ q | to_nice_yaml }}",
        }
    )
    copier.run_copy(str(src), dst, data={"q": ["three", "one", "two"]})
    assert yaml.safe_load((dst / "q.yml").read_text()) == ["one", "two", "three"]


@pytest.mark.parametrize(
    "spec",
    [
        (
            """\
            choices: '["one", "two", "three"]'
            """
        ),
        (
            """\
            choices: '[{% for c in ["one", "two", "three"] %}{{ c }}{% if not loop.last %},{% endif %}{% endfor %}]'
            """
        ),
        (
            """\
            choices: |
                {%- for c in ['one', 'two', 'three'] %}
                - {{ c }}
                {%- endfor %}
            """
        ),
        (
            """\
            choices: |
                {%- for c in ['one', 'two', 'three'] %}
                {{ c }}: null
                {%- endfor %}
            """
        ),
        (
            """\
            choices: |
                {%- for c in ['one', 'two', 'three'] %}
                {{ c|upper }}: {{ c }}
                {%- endfor %}
            """
        ),
        (
            """\
            choices: |
                {%- for c in ['one', 'two', 'three'] %}
                - [{{ c|upper }}, {{ c }}]
                {%- endfor %}
            """
        ),
        (
            """\
            choices: |
                {%- for c in ['one', 'two', 'three'] %}
                {{ c|upper }}:
                    value: {{ c }}
                {%- endfor %}
            """
        ),
        (
            """\
            choices: |
                {%- for c in ['one', 'two', 'three'] %}
                {{ c|upper }}:
                    value: {{ c }}
                    validator: "{% raw %}{% if false %}Validation error{% endif %}{% endraw %}"
                {%- endfor %}
            """
        ),
    ],
)
def test_templated_choices(tmp_path_factory: pytest.TempPathFactory, spec: str) -> None:
    src, dst = map(tmp_path_factory.mktemp, ("src", "dst"))
    build_file_tree(
        {
            (src / "copier.yml"): f"q:\n    type: str\n{indent(dedent(spec), ' ' * 4)}",
            (src / "q.txt.jinja"): "{{ q }}",
        }
    )
    copier.run_copy(str(src), dst, data={"q": "two"})
    assert yaml.safe_load((dst / "q.txt").read_text()) == "two"


@pytest.mark.parametrize(
    ("spec", "expected"),
    [
        (
            """\
            type: str
            choices: ["one", "two", "three"]
            multiselect: true
            default: "{{ '[one, three]' }}"
            """,
            ["one", "three"],
        ),
        (
            """\
            type: str
            choices:
                one: '[1]'
                two: '[2]'
                three: '[3]'
            multiselect: true
            # NOTE: Balanced (nested) brackets don't need to be quoted because the
            # shallow list parser retains raw list items
            default: "{{ '[[1], [3]]' }}"
            """,
            ["[1]", "[3]"],
        ),
        (
            """\
            type: str
            choices:
                left: '['
                right: ']'
            multiselect: true
            # NOTE: Unbalanced brackets need to be explicitly quoted to avoid ambiguity
            # with the surrounding list brackets.
            default: >
                {{ '["[", "]"]' }}
            """,
            ["[", "]"],
        ),
        (
            """\
            type: int
            choices: [1, 2, 3]
            multiselect: true
            default: "{{ '[1, 3]' }}"
            """,
            [1, 3],
        ),
        (
            """\
            type: int
            choices: ['1', '2', '3']
            multiselect: true
            default: >
                {{ '["1", "3"]' }}
            """,
            [1, 3],
        ),
        (
            """\
            type: float
            choices: [1.0, 2.0, 3.0]
            multiselect: true
            default: "{{ '[1.0, 3.0]' }}"
            """,
            [1.0, 3.0],
        ),
        (
            """\
            type: float
            choices: ['1.0', '2.0', '3.0']
            multiselect: true
            default: >
                {{ '["1.0", "3.0"]' }}
            """,
            [1.0, 3.0],
        ),
        (
            """\
            type: json
            choices:
                one: '{"value": 1}'
                two: '{"value": 2}'
                three: '{"value": 3}'
            multiselect: true
            default: |
                {%- if true %}
                - '{"value": 1}'
                - '{"value": 3}'
                {%- endif %}
            """,
            [{"value": 1}, {"value": 3}],
        ),
        (
            """\
            type: yaml
            choices:
                one: '{value: [1, null]}'
                two: '{value: [2, null]}'
                three: '{value: [3, null]}'
            multiselect: true
            default: |
                {%- if true %}
                - {value: [1, null]}
                - {value: [3, null]}
                {%- endif %}
            """,
            [{"value": [1, None]}, {"value": [3, None]}],
        ),
        (
            """\
            type: yaml
            choices:
                one: '{value: [1, null]}'
                two: '{value: [2, null]}'
                three: '{value: [3, null]}'
            multiselect: true
            default: |
                {%- if true %}
                - '{value: [1, null]}'
                - '{value: [3, null]}'
                {%- endif %}
            """,
            [{"value": [1, None]}, {"value": [3, None]}],
        ),
        (
            """\
            type: yaml
            choices:
                one: '{value: "[1]"}'
                two: '{value: "[2]"}'
                three: '{value: "[3]"}'
            multiselect: true
            default: |
                {%- if true %}
                - {value: '[1]'}
                - {value: '[3]'}
                {%- endif %}
            """,
            [{"value": "[1]"}, {"value": "[3]"}],
        ),
    ],
)
def test_multiselect_choices_with_templated_default_value(
    tmp_path_factory: pytest.TempPathFactory, spec: str, expected: Any
) -> None:
    src, dst = map(tmp_path_factory.mktemp, ("src", "dst"))
    build_file_tree(
        {
            (src / "copier.yml"): f"q:\n{indent(dedent(spec), ' ' * 4)}",
            (src / "q.yml.jinja"): "{{ q | to_nice_yaml }}",
        }
    )
    copier.run_copy(str(src), dst, defaults=True)
    assert yaml.safe_load((dst / "q.yml").read_bytes()) == expected


def test_copier_phase_variable(tmp_path_factory: pytest.TempPathFactory) -> None:
    src, dst = map(tmp_path_factory.mktemp, ("src", "dst"))
    build_file_tree({src / "{{ _copier_phase }}.jinja": "{{ _copier_phase }}"})
    copier.run_copy(str(src), dst)
    assert (dst / "render").exists()
    assert (dst / "render").read_text() == "render"


def test_relative_render_path_outside_destination_raises_error(
    tmp_path_factory: pytest.TempPathFactory,
) -> None:
    root = tmp_path_factory.mktemp("root")
    (src := root / "src").mkdir()
    (dst := root / "dst").mkdir()
    build_file_tree(
        {
            root / "forbidden.txt": "foo",
            src / "{{ pathjoin('..', 'forbidden.txt') }}": "bar",
        }
    )
    with pytest.raises(
        ForbiddenPathError,
        match=re.escape(rf'"{Path("..", "forbidden.txt")}" is forbidden'),
    ):
        copier.run_copy(str(src), dst, overwrite=True)
    assert not (dst / "forbidden.txt").exists()
    assert (root / "forbidden.txt").exists()
    assert (root / "forbidden.txt").read_text("utf-8") == "foo"


@pytest.mark.skipif(os.name != "posix", reason="Applies only to POSIX")
def test_absolute_render_path_outside_destination_raises_error_on_posix(
    tmp_path_factory: pytest.TempPathFactory,
) -> None:
    src, dst, other = map(tmp_path_factory.mktemp, ("src", "dst", "other"))
    path_parts = ", ".join(map(repr, other.parts[1:]))
    build_file_tree(
        {
            other / "forbidden.txt": "foo",
            src / "copier.yml": f"_envops: {BRACKET_ENVOPS_JSON}",
            src / f"[[ pathjoin(_copier_conf.sep, {path_parts}, 'forbidden.txt') ]]": (
                "bar"
            ),
        }
    )
    with pytest.raises(
        ForbiddenPathError,
        match=re.escape(rf'"{other / "forbidden.txt"}" is forbidden'),
    ):
        copier.run_copy(str(src), dst, overwrite=True)
    assert not (dst / "forbidden.txt").exists()
    assert (other / "forbidden.txt").exists()
    assert (other / "forbidden.txt").read_text("utf-8") == "foo"


@pytest.mark.skipif(os.name != "nt", reason="Applies only to Windows")
def test_absolute_render_path_outside_destination_raises_error_on_windows(
    tmp_path_factory: pytest.TempPathFactory,
) -> None:
    src, dst, other = map(tmp_path_factory.mktemp, ("src", "dst", "other"))
    path_parts = ", ".join(map(repr, other.parts[1:]))
    build_file_tree(
        {
            other / "forbidden.txt": "foo",
            src / "copier.yml": (
                f"""\
                _envops: {BRACKET_ENVOPS_JSON}

                drive:
                    type: str
                    default: "{other.drive}"
                    when: false
                """
            ),
            src / f"[[ pathjoin(drive, {path_parts}, 'forbidden.txt') ]]": "bar",
        }
    )
    with pytest.raises(
        ForbiddenPathError,
        match=re.escape(rf'"{other / "forbidden.txt"}" is forbidden'),
    ):
        copier.run_copy(str(src), dst, overwrite=True)
    assert not (dst / "forbidden.txt").exists()
    assert (other / "forbidden.txt").exists()
    assert (other / "forbidden.txt").read_text("utf-8") == "foo"


def test_skipped_question_with_unset_default_value(
    tmp_path_factory: pytest.TempPathFactory,
) -> None:
    src, dst = map(tmp_path_factory.mktemp, ("src", "dst"))
    build_file_tree(
        {
            src / "copier.yml": (
                """\
                q:
                    type: str
                    default: "{{ UNSET }}"
                    when: false
                    validator: "{% if not q %}Invalid{% endif %}"
                """
            ),
            src / "output.txt.jinja": "{{ question | default('<undefined>') }}",
        }
    )
    copier.run_copy(str(src), dst, defaults=True)
    assert (dst / "output.txt").read_text("utf-8") == "<undefined>"


def test_warn_if_chmod_raises_permission_error(
    tmp_path_factory: pytest.TempPathFactory,
) -> None:
    """Test that a warning is raised when `chmod` raises a `PermissionError`.

    Changing path permissions is not allowed in some filesystems like gcsfuse. In this
    case, Copier should not fail but suppress the error and raise a warning.
    """
    src, dst = map(tmp_path_factory.mktemp, ("src", "dst"))
    build_file_tree(
        {
            src / "test.txt": "foo",
            dst / "test.txt": "bar",
        }
    )

    # Ensure that the source and destination files have different permissions, so
    # Copier will want to change the permissions of the destination file to those of
    # the source file.
    (src / "test.txt").chmod(0o400)
    (dst / "test.txt").chmod(0o600)

    dst_file_mode = (dst / "test.txt").stat().st_mode

    def mocked_chmod(self: Path, mode: int) -> None:
        if self == dst / "test.txt":
            raise PermissionError("Operation not permitted")
        return chmod(self, mode)

    chmod = Path.chmod

    with (
        mock.patch.object(Path, "chmod", new=mocked_chmod),
        pytest.warns(UserWarning, match=r"^Path permissions for .+? cannot be changed"),
    ):
        copier.run_copy(str(src), dst, overwrite=True)

    assert (dst / "test.txt").stat().st_mode == dst_file_mode
    assert (dst / "test.txt").read_text("utf-8") == "foo"
